iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Claude AI

從零打造圖書管理系統:WSL2 × MySQL × Claude Code 的整合實作系列 第 17 篇

Day 17 借書功能 — 檢查庫存、紀錄借閱與書籍數量的邏輯

  • 分享至 

  • xImage
  •  

完成會員註冊與登入驗證後,今天要進入系統最核心的業務邏輯之一:借書功能。

當會員點擊借書時,後端不能只是單純在資料庫新增一筆紀錄,還必須處理書籍庫存的異動與狀態檢查。

借書的核心業務邏輯

  1. 檢查身分:確認使用者是否已登入(讀取 $_SESSION['member_id'])。
  2. 檢查書籍狀態:確認要借的書籍是否存在,且目前是否有剩餘庫存(quantity > 0)。
  3. 新增借閱紀錄:在 loans 資料表新增一筆紀錄,包含 member_id、book_id、borrowed_at(借出時間)與預計歸還時間。
  4. 書籍庫存:將 books 資料表中該本書的 quantity 數量減 1。

請 Claude Code 幫忙產生借書邏輯

在專案目錄下啟動 Claude Code 下達需求:

請幫我寫一個 borrow.php。當使用者透過 POST 請求傳入 book_id 時:
1. 檢查使用者是否已登入,若未登入則導向 login.php。
2. 查詢 books 資料表確認該書籍庫存(quantity)是否大於 0。
3. 若有庫存,在 loans 資料表新增一筆借閱紀錄(包含 member_id、book_id、borrowed_at),並將 books 資料表中該書籍的 quantity 減 1。
4. 處理完成後導回 index.php 並顯示成功或失敗訊息。

生成的 borrow.php:

<?php

// ============================================================
// 【PHP】區塊:啟動 Session
// ============================================================
session_start(); // 必須在任何輸出之前呼叫,之後才能讀寫 $_SESSION(登入狀態與提示訊息都存在這裡)

// ============================================================
// 【PHP】區塊:檢查是否已登入
// ============================================================
// login.php 登入成功時會寫入 $_SESSION['member_id'],沒有這個值就代表尚未登入
if (!isset($_SESSION['member_id'])) {
    header('Location: login.php'); // header() 送出 HTTP 標頭,Location 會讓瀏覽器跳轉到指定頁面
    exit; // 導向後立刻結束程式,避免下方借書邏輯繼續執行
}

// ============================================================
// 【PHP】區塊:只接受 POST 請求
// ============================================================
// $_SERVER['REQUEST_METHOD'] 是這次請求的方法(GET / POST),借書會改動資料,只允許表單 POST 觸發
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Location: index.php');
    exit;
}
// ============================================================
// 【PHP】區塊:驗證 book_id
// ============================================================
$bookId = $_POST['book_id'] ?? null; // ?? 是 null 合併運算子:左邊不存在或為 null 時改用右邊的值

// ctype_digit() 檢查字串是否全為數字,排除負數、小數與非數字輸入
if ($bookId === null || !ctype_digit((string) $bookId) || (int) $bookId < 1) {
    $_SESSION['flash'] = ['type' => 'error', 'message' => '書籍編號不正確,借閱失敗']; // 把提示訊息存進 Session,讓 in>
    header('Location: index.php');
    exit;
}

$bookId = (int) $bookId; // (int) 是型別轉換,把字串轉成整數
$memberId = (int) $_SESSION['member_id']; // 借閱者就是目前登入的會員

// ============================================================
// 【PHP】區塊:載入資料庫連線
// ============================================================
require __DIR__ . '/config/database.php'; // __DIR__ 是目前檔案所在的資料夾路徑,確保路徑正確

$pdo = Database::getConnection(); // :: 用來呼叫類別的 static 方法,取得 PDO 連線

// ============================================================
// 【PHP】區塊:借書流程(使用交易確保資料一致)
// ============================================================
ry { // try...catch 捕捉執行過程拋出的例外(Exception)
    // 開始交易:新增借閱紀錄與扣庫存必須同時成功,任一步失敗就全部復原
    $pdo->beginTransaction();

    // ------------------------------------------------------------
    // 子區塊:查詢書籍庫存
    // ------------------------------------------------------------
    // FOR UPDATE 會鎖住這筆書籍資料直到交易結束,避免兩人同時借走最後一本
    $stmt = $pdo->prepare('SELECT title, available FROM books WHERE id = :id FOR UPDATE'); // prepare() 建立 prepared s>
    $stmt->execute([':id' => $bookId]); // 把實際值綁定到佔位符並執行查詢
    $book = $stmt->fetch(); // 取出一筆資料,查無資料時回傳 false

    if ($book === false) {
        $pdo->rollBack(); // 復原交易並釋放鎖定
        $_SESSION['flash'] = ['type' => 'error', 'message' => '找不到這本書,借閱失敗'];
        header('Location: index.php');
        exit;
    }
    // books 資料表用 available 欄位記錄目前可借數量
    if ((int) $book['available'] <= 0) {
        $pdo->rollBack();
        $_SESSION['flash'] = ['type' => 'error', 'message' => "《{$book['title']}》目前沒有庫存,借閱失敗"]; // 雙引號[>
        header('Location: index.php');
        exit;
    }

    // ------------------------------------------------------------
    // 子區塊:新增借閱紀錄
    // ------------------------------------------------------------
    // NOW() 是 MySQL 函式,取得目前時間;due_date 為必填欄位,預設借期 14 天
    $sql = 'INSERT INTO loans (member_id, book_id, borrowed_at, due_date, status)
            VALUES (:member_id, :book_id, NOW(), DATE_ADD(CURDATE(), INTERVAL 14 DAY), :status)';
    $stmt = $pdo->prepare($sql);
    $stmt->execute([
        ':member_id' => $memberId,
        ':book_id' => $bookId,
        ':status' => 'borrowed',
    ]);
    / ------------------------------------------------------------
    // 子區塊:可借數量減 1
    // ------------------------------------------------------------
    $stmt = $pdo->prepare('UPDATE books SET available = available - 1 WHERE id = :id');
    $stmt->execute([':id' => $bookId]);

    $pdo->commit(); // 提交交易,兩個寫入動作正式生效

    $_SESSION['flash'] = ['type' => 'success', 'message' => "成功借閱《{$book['title']}》,請於 14 天內歸還"];
} catch (PDOException $e) { // PDOException 是資料庫操作失敗時拋出的例外類別
    // inTransaction() 判斷交易是否仍在進行中,是的話才需要 rollBack
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }
    $_SESSION['flash'] = ['type' => 'error', 'message' => '借閱時發生錯誤,請稍後再試'];
}

// ============================================================
// 【PHP】區塊:處理完成導回首頁
// ============================================================
header('Location: index.php');
exit;

功能測試與驗證

  1. 未登入測試:在未登入狀態下嘗試對 borrow.php 發送請求,系統順利攔截並導向 login.php。
  2. 正常借書測試:登入會員後點擊書籍旁邊的「借閱」按鈕,頁面顯示成功訊息,資料庫 loans 表成功新增一筆紀錄,且該書籍的 quantity 確實少 1。
    圖片描述
    圖片描述
  3. 無庫存測試:把書籍庫存扣到 0 後再次點擊借閱,頁面跳出「該書籍目前已無庫存可借」的提示,且 quantity 不會變為負數。

今天完成了基礎的借書流程,看起來一切運作正常。

但是,現在的程式碼隱藏著一個漏洞:如果兩個人在同一時間點擊借同一本只剩 1 本的書,會發生什麼事?

明天將模擬這個「兩個人同時借同一本書」的問題,看看 Race Condition 是如何打破系統邏輯!


上一篇
Day 16 會員登入實作、password_verify 與 Session 狀態管理
系列文
從零打造圖書管理系統:WSL2 × MySQL × Claude Code 的整合實作 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言